feat(api): publish the deployment's OpenAPI schema - #358
Merged
Conversation
fazpu
force-pushed
the
feat/publish-openapi-schema
branch
8 times, most recently
from
September 3, 2026 03:58
a4f555b to
e6781e3
Compare
The HTTP surface is the contract every client codes against, but it was only discoverable by reading `http_api.py`, so consumers restated it by hand. The cloud UI's data-plane types say so in their own docstring: hand-written, able to drift silently, with generating them named as the correct fix and no pipeline to do it. This adds the pipeline. `scripts/export_openapi.py` builds the app in-process and asks FastAPI for the document offline — no server, no port. The app keeps `openapi_url=None`, because that route is not covered by the auth perimeter and serving it would hand the surface to unauthenticated callers. Refusing to serve the schema and refusing to publish it are different choices; this repo makes only the first, and the document ships as a release asset. Four ways the document was wrong before it was right: - **Too few routes.** `/deployment` was registered on the app by the self-host profile after `build_api` returned, so a reader of `http_api.py` saw an incomplete surface. It is now composed through a `BuildInfoPort`, so every documented route is declared in one place. `/healthz` is still added by the profile, deliberately: it is the container's liveness probe rather than part of the query API, and carries `include_in_schema=False`. - **Too many routes.** The export composed a `connectors` port the profile never passes, publishing four `/connectors` operations that answer 404 on every deployment the shipped profile builds. - **No credential.** A guarded deployment rejects unauthenticated calls, so a client generated from a document that never mentions credentials compiles and then fails on first contact. `HTTPBearer(auto_error=False)` declares the scheme without ever raising, leaving `_perimeter` the sole enforcement point. - **No version.** FastAPI defaults to 0.1.0, which would make every published asset indistinguishable. The guard against the second kind went through five instruments before the right answer turned out to be fewer, not more. Naming four routes missed the ten it did not name. Freezing the whole route set froze phantom routes as readily as real ones. Comparing the two `build_api` call sites by AST caught `connectors` but compared keyword spelling rather than composition — `auth` resolves to None for the open quickstart, so the profile names capabilities a deployment may not compose — and was then defeated six ways in review, each time by a construction the previous fix had not imagined. Static analysis cannot soundly answer "is there exactly one way to call this", so that machinery is gone. What remains is a frozen route set that is **reviewed rather than derived**: the published surface cannot change without a person editing the list and saying why. That is what would have caught the phantom routes — not the machine, but the edit being visible. Two tests were deleted mid-development by edits that rewrote the surrounding block. Nothing failed, because a deleted test cannot fail, and the claim each supported stayed in the commit message describing a guarantee that no longer existed. A count assertion now notices, and caught a miscount while this change was being made. Verified to fail: a stale committed schema; an export dropping the operations and open-query ports; one dropping build info; one dropping auth; an unclassified new optional parameter on `build_api`; and a deleted test. The auth test exercises `GET /healthz` as well as a gated route, because only the exempt route can tell the difference: on a gated route the perimeter answers first and an `auto_error=True` scheme looks harmless, while on the liveness probe it would refuse the container's health check. Also records, in the release workflow where the mistake was made, that GitHub prefixes a dotfile asset with "default" — `.env.example` is published as `default.env.example` and by no other name. Four fleet engine pins recorded the source name, copied forward from the first, because they were written from that file list rather than from a release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UxXnThvjreGrY7c7CKM6TC
fazpu
force-pushed
the
feat/publish-openapi-schema
branch
from
September 3, 2026 04:11
e6781e3 to
011ab23
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the gap the cloud UI's data-plane types name in their own docstring:
This is that pipeline.
scripts/export_openapi.pybuilds the app in-process and asks FastAPI for the document — no server, no port. The app keepsopenapi_url=None: refusing to serve the schema and refusing to publish it are different choices, and only the first is correct. The schema route sits outside the auth perimeter.Four ways the document was wrong before it was right
All found by @codex.
/deploymentregistered by the profile afterbuild_apiBuildInfoPortconnectors, which the profile never passesHTTPBearer(auto_error=False)— declares, never raises0.1.0)Routes: 19 → 29 → 25. The middle number is the point — more is not better when the extras don't exist.
The guard got smaller, and that was the fix
Five instruments, each defeated:
/query/*, and/deployment. A missing route is invisible to a test that only asks about routes it remembered./connectorsroutes as readily as the real ones.connectors, but compared keyword spelling, which is not composition:auth=resolve_selfhost_api_auth(...)returnsNonefor the open quickstart.**kwargs, second call, qualified call, aliased import, rebinding. codex produced two more that walked through.build_apimust be the callee. codex produced a sixth:import build_api as otherplus a parameter defaultbuild_api=other, so the counted call is a decoy.Static analysis cannot soundly answer "is there exactly one way to call this." That machinery is gone.
What remains is a frozen route set that is reviewed, not derived: the published surface cannot change without a person editing the list and saying why. That is what would have caught the phantom routes in the first place — not the machine, but the edit being visible in a diff.
Verified to fail:
openapi.jsonsurface=None, open_query=Nonebuild_info=None/deployment)auth=Nonebuild_apiTwo tests I deleted without noticing
Mid-development, edits that rewrote a block of the test file removed a test each time. Nothing failed — a deleted test cannot fail — and the claim each supported stayed in my commit message describing a guarantee that no longer existed. codex caught the second.
There's now a count assertion. It's crude, and that's the point. It caught a miscount while this very change was being made.
A vacuous test I had to rewrite
My first auth test asserted 401 on a gated route — and passed with
auto_error=True, because the perimeter raises first. The real risk wasGET /healthz, which is perimeter-exempt:auto_error=Truewould have refused the container's liveness probe while every other route kept working.Claims corrected along the way
Each of these was false when written, and each was caught in review rather than by me: that the compositions "cannot drift apart"; that the profile composes
spend_lease; thatHTTPBearerreturns 403 (the pinned FastAPI returns 401); that/connectorswould 404 on every deployment (a programmatic one can compose it); that this module is the "single place the HTTP surface is defined" (/healthzis still added by the profile, deliberately, and excluded from the schema); and that the docs page's every route is in the schema.Docs (D66)
/docs/reference/apigains The machine-readable schema — where to get it, why the deployment doesn't serve it, how to generate a client, and the distinction review surfaced: capabilities nobody ships are absent, while capabilities you haven't configured are still listed, because no check on a published document can know your settings./docs/project-statusgains one bullet.Site builds; 22 pages indexed.
The dotfile naming, recorded where the mistake was made
.env.exampleis published by GitHub asdefault.env.example— the API prefixes dotfile assets withdefault. Root cause of the fleet pin defect fixed in umc #419: four pins recorded the source name because whoever wrote the first read this workflow's file list rather than a release's asset list.Checks
ruff, ruff format, pyright (0 errors, repo-wide); 430 surfaces tests pass; test inventory OK.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UxXnThvjreGrY7c7CKM6TC
Contributor agreement
Signing on behalf of a legal entity (leave blank if accepting individually):